// © 2019 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
package org.unicode.icu.tool.cldrtoicu.mapper;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.Ordering.natural;

import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import org.unicode.cldr.api.CldrData;
import org.unicode.cldr.api.CldrPath;
import org.unicode.cldr.api.CldrValue;
import org.unicode.icu.tool.cldrtoicu.IcuData;
import org.unicode.icu.tool.cldrtoicu.PathValueTransformer;
import org.unicode.icu.tool.cldrtoicu.PathValueTransformer.Result;
import org.unicode.icu.tool.cldrtoicu.RbPath;
import org.unicode.icu.tool.cldrtoicu.RbValue;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.SetMultimap;

/**
 * An abstract parent class for any mappers based on {@code PathValueTransformer}. This ensures
 * that transformation results are correctly processed when being added to IcuData instances.
 */
abstract class AbstractPathValueMapper {
    // Matches "/foo/bar" or "/foo/bar[N]" as a resource bundle path, capturing the path and
    // optional index separately. Note that this is very sloppy matching and the path string will
    // also be parsed via RbPath.parse().
    private static final Pattern ARRAY_INDEX = Pattern.compile("(/[^\\[]++)(?:\\[(\\d++)])?$");

    private final CldrData cldrData;
    private final PathValueTransformer transformer;

    // WARNING: TreeMultimap() is NOT suitable here, even though it would sort the values for
    // each key. The reason is that result comparison is not "consistent with equals", and
    // TreeMultimap uses the comparator to decide if two elements are equal (not the equals()
    // method), and it does this even if using the add() method of the sorted set (this is in
    // fact in violation of the stated behaviour of Set#add).
    private final SetMultimap<RbPath, Result> resultsByRbPath = LinkedHashMultimap.create();

    AbstractPathValueMapper(CldrData cldrData, PathValueTransformer transformer) {
        this.cldrData = checkNotNull(cldrData);
        this.transformer = checkNotNull(transformer);
    }

    /**
     * Post-processes results generated by calling the subclass method {@link #addResults()}. This
     * is the only method which need be directly invoked by the sub-class implementation (other
     * methods are optionally used from within the {@link #addResults()} callback).
     */
    final void addIcuData(IcuData icuData) {
        // This subclass mostly exists to control the fact that results need to be added in one go
        // to the IcuData because of how referenced paths are handled. If results could be added in
        // multiple passes, you could have confusing situations in which values have path references
        // in them, but the referenced paths have not been transformed yet. Forcing the subclass to
        // implement a single method to generate all results at once ensures that we control the
        // lifecycle of the data and how results are processed as they are added to the IcuData.
        checkState(resultsByRbPath.isEmpty(),
            "results must not be added outside the call to addResults(): %s", resultsByRbPath);
        addResults();
        addResultsToIcuData(finalizeResults(), icuData);
        resultsByRbPath.clear();
    }

    /**
     * Implemented by sub-classes to return all results to be added to the IcuData instance. The
     * primary job of this callback is to generate transformed results (typically by calling
     * {@link #transformValue(CldrValue)}) and then, after optional post-processing, add the
     * results to this mapper using {@link #addResult(RbPath, Result)}.
     *
     * <p>This method is called once for each call to {@link #addIcuData(IcuData)} and
     * is responsible for adding all necessary results for the returned {@link IcuData}.
     */
    abstract void addResults();

    /**
     * Returns the CLDR data used for this transformation. Note that a subclass mapper might have
     * other data for different purposes, but this data instance is the one from which variables
     * are resolved. A sub-class mapper might access this for additional processing.
     */
    final CldrData getCldrData() {
        return cldrData;
    }

    /**
     * Transforms a single value into a sequence of results using this mapper's {@link
     * PathValueTransformer}, which can be added to the mapper (possibly after optional
     * post-processing).
     */
    final Stream<Result> transformValue(CldrValue value) {
        return transformer.transform(value, this::getVarsFn).stream();
    }

    /**
     * Adds a transformed result to the mapper. This should be called by the sub-class mapper in
     * its implementation of the {@link #addResults()} method.
     *
     * <p>Note that the given path will often (but not always) be just the path of the result.
     */
    final void addResult(RbPath path, Result result) {
        resultsByRbPath.put(path, result);
    }

    // Callback function used by the transform() method to resolve variables from CLDR data.
    private String getVarsFn(CldrPath p) {
        CldrValue cldrValue = cldrData.get(p);
        return cldrValue != null ? cldrValue.getValue() : null;
    }

    // Fills in any fallback results and orders the results by the resource bundle path.
    private ImmutableListMultimap<RbPath, Result> finalizeResults() {
        ImmutableListMultimap.Builder<RbPath, Result> out = ImmutableListMultimap.builder();
        out.orderValuesBy(natural());
        for (RbPath rbPath : resultsByRbPath.keySet()) {
            Set<Result> existingResults = resultsByRbPath.get(rbPath);
            out.putAll(rbPath, existingResults);
            for (Result fallback : transformer.getFallbackResultsFor(rbPath, this::getVarsFn)) {
                if (existingResults.stream().noneMatch(fallback::isFallbackFor)) {
                    out.put(rbPath, fallback);
                }
            }
        }
        return out.build();
    }

    /**
     * Adds transformation results on the specified multi-map to this data instance. Results are
     * processed in list order and handled differently according to whether they are grouped, or
     * represent an alias value.
     *
     * If the value of an ungrouped result is itself a resource bundle path (including possibly
     * having an array index) then the referenced value is assumed to be an existing path whose
     * value is then substituted.
     */
    private static void addResultsToIcuData(
        ImmutableListMultimap<RbPath, Result> results, IcuData icuData) {

        // Ordering of paths should not matter here (IcuData will re-sort them) and ordering of
        // values for a given key is preserved by list multimaps.
        ListMultimap<RbPath, ValueOrAlias> map = ArrayListMultimap.create();

        // IMPORTANT: This code MUST use the keys of the results map (rather than extracting the
        // paths from the results). This is because paths can be post-processed after the result
        // is obtained, which can affect output ordering as well as the path mappings.
        for (RbPath rbPath : results.keySet()) {
            for (Result r : results.get(rbPath)) {
                if (r.isGrouped()) {
                    // Grouped results have all values in a single entry and cannot be aliases.
                    map.put(rbPath, ValueOrAlias.value(RbValue.of(r.getValues())));
                } else if (rbPath.isAlias()) {
                    // Aliases (which should be single values) are not expanded to their referenced
                    // values (whereas non-aliases might be). This is really just a hack to work
                    // around the fact that RbPath/RbValue is not properly typed and we have to use
                    // heuristics to determine whether to replace a resource bundle path with its
                    // referenced value.
                    checkArgument(r.getValues().size() == 1,
                        "explicit aliases must be singleton values: %s", r);
                    map.put(rbPath, ValueOrAlias.value(Iterables.getOnlyElement(r.getValues())));
                } else {
                    // Ungrouped results are one value per entry, but might later be expanded into
                    // grouped results if they are a path referencing a grouped entry.
                    r.getValues().forEach(v -> map.put(rbPath, ValueOrAlias.parse(v)));
                }
            }
        }
        // This works because insertion order is maintained for values of each path.
        map.forEach((p, v) -> icuData.add(p, v.resolve(map)));
    }

    /*
     * An unfortunately messy little interface to handle to way that aliases are defined in the
     * path value mappers. A mapper Result is permitted to contain values which are actually
     * aliases to other resource bundle elements. This is typically used in fallback values, where
     * the fallback is a functional value. For example:
     *    fallback=/weekData/001:intvector[0]
     *
     * This is messy because when we process the Results from the mapper to put them into the
     * IcuData instance, we cannot be sure we can resolve these "aliases" at the time that they
     * are encountered (the target value might not be present yet). So we need to wait until
     * all the values are in place and then do a 2nd pass to resolve things.
     *
     * So far path replacement is strictly limited to fallback results, so perhaps it could be
     * handled more directly in the Result class, though it is possible for a single result to
     * contain multiple path references:
     *     fallback=/weekData/001:intvector[2] /weekData/001:intvector[3]
     */
    private interface ValueOrAlias {
        // A simple value doesn't need resolving, and doesn't care if the given map is null (*).
        static ValueOrAlias value(RbValue v) {
            return src -> v;
        }

        // Helper for (common) singleton values.
        static ValueOrAlias value(String v) {
            return value(RbValue.of(v));
        }

        static ValueOrAlias parse(String valueOrAlias) {
            Matcher m = ARRAY_INDEX.matcher(valueOrAlias);
            if (!m.matches()) {
                return value(valueOrAlias);
            }
            // The only constraint is that the "path" value starts with a leading '/', but parsing into
            // the RbPath ignores this. We must use "parse()" here, rather than RbPath.of(), since the
            // captured value contains '/' characters to represent path delimiters.
            RbPath path = RbPath.parse(m.group(1));
            // If no index is given (e.g. "/foo/bar") then treat it as index 0 (i.e. "/foo/bar[0]").
            int index = m.group(2) != null ? Integer.parseUnsignedInt(m.group(2)) : 0;
            return src -> {
                checkState(src != null, "recursive alias resolution is not supported");
                List<ValueOrAlias> values = src.get(path);
                checkArgument(!values.isEmpty(), "no such alias value: /%s", path);
                checkArgument(index < values.size(),
                    "index for alias /%s[%s] is out of bounds", path, index);
                // By passing 'null' to the recursive call to resolve, we prevent the resolution
                // from being recursive (*). This could be changed to pass 'src' and achieve
                // arbitrary recursive resolving if needed, put that's currently unnecessary (and
                // should probably be guarded against unbounded recursion if it is ever enabled).
                return values.get(index).resolve(null);
            };
        }

        RbValue resolve(ListMultimap<RbPath, ValueOrAlias> src);
    }
}
